가상화 리스트의 동적 높이 추정 및 스크롤 성능 개선
마지막 수정일 · 2026. 04. 27.
TL;DR
전자차트에서 한 환자의 진료 기록이 수백 건 쌓인 케이스를 길게 스크롤하면 스크롤바 thumb이 위아래로 튀고 콘텐츠가 점프했다. 가상화는 이미 @tanstack/react-virtual로 적용돼 있던 상태였다.
- 원인은 프레임이 아니라
estimateSize: () => 200. 행의 실제 높이가 50~600px로 벌어지는데 전부 200px로 추정하고 있었다 - off-screen 행이 화면에 들어오는 순간
measureElement가 실측으로 보정 →getTotalSize()가 계속 변함 → 스크롤바 thumb의 비율과 위치가 같이 튐. 이게 "scroll jank"의 정체였다 - 해결은 off-screen 행도 화면에 들어오기 전에 거의 정확한 높이를 알게 하는 것. DOM 마운트 없이 text wrap 높이를 재기 위해 캔버스 글리프 측정(`@chenglou/pretext`)을 썼다
- 구조는 2단계로 뒀다. pretext = off-screen 1차 근사,
measureElement= on-screen 최종 보정 - 핵심 한 줄:
estimateSize의 정확도 자체가 가상 스크롤바의 안정성과 직결된다
문제상황
증상은 단순히 "스크롤이 버벅거린다"가 아니었다. 스크롤바 thumb의 위치와 길이가 스크롤하는 동안 계속 바뀌는 것이 본질이었다.
가상화 설정은 이랬다.
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableBodyRef.current,
estimateSize: () => 200, // ← 모든 row를 200px로 가정
overscan: 5,
measureElement: el => el?.getBoundingClientRect().height,
})
진료 기록 한 줄은 타입에 따라 실제 높이가 천차만별이다.
| 레코드 타입 | 실제 높이 |
|---|---|
< C/C > 같은 한 줄짜리 메모 |
약 50~60px |
| 진단 + 시술 detail + 메모 + 썸네일 | 200~600px |
| 인라인 그리드가 여러 개 붙는 계획 항목 | 100~300px |
| 본문 + 메모 + 썸네일이 모두 붙은 경우 | 400px 이상 |
전부 () => 200으로 추정하니, off-screen 행들은 실제와 한참 다른 가짜 높이로 자리를 차지하고 있다가 스크롤로 화면에 들어오는 순간 실측으로 보정된다. 이 과정에서 벌어지는 일은 이렇다.
- 그 행의 size가 200 → 실제 측정값(예: 480)으로 바뀌고
rowVirtualizer.getTotalSize()가 그만큼 같이 바뀌고- 가상화 컨테이너의 총 높이가 변하니까 스크롤바 thumb의 비율과 위치가 같이 튄다
스크롤을 내리는 도중에 이 보정이 행 단위로 계속 일어나니까, 사용자 입장에서는 thumb이 떨고 콘텐츠가 점프하는 것처럼 보인다.
여기서 중요한 건 measureElement는 잘못한 게 없다는 점이다. 그건 원래 실측 보정을 하라고 있는 것이고, 정확히 그 일을 하고 있었다. 문제는 보정 대상인 추정값이 너무 부정확해서 보정 폭 자체가 눈에 보일 만큼 컸다는 데 있었다.
해결 방법
핵심 아이디어는 하나다. off-screen 행도 화면에 들어오기 전에 거의 정확한 높이를 알게 하자. 그러면 measureElement가 보정해도 getTotalSize() 변동이 0에 수렴한다.
DOM에 마운트하지 않고 텍스트 높이(줄바꿈 포함)를 정확히 재려면 캔버스 기반 글리프 측정이 필요하다. prepare(text, font) + layout(prepared, width, lineHeight) 만으로 text wrap 결과의 픽셀 높이를 받을 수 있고, DOM reflow가 없으므로 수백 행을 한꺼번에 측정해도 비용이 거의 없다.
1) 행 높이 추정 모듈
별도 모듈에 description 컬럼의 레이아웃을 그대로 모사했다. 각 레코드를 "block"의 리스트로 변환한다.
type Block =
| { kind: "text"; text: string; bold?: boolean; indent?: number }
| { kind: "fixed"; height: number } // 인라인 그리드, 썸네일 등
| { kind: "gap"; height: number } // mt-1, space-y-1 같은 여백
레코드 타입별로 분기해서 블록 리스트를 만들고, 각 text block을 pretext로 측정한다.
const prepared = prepare(block.text, block.bold ? FONT_BOLD : FONT, {
whiteSpace: "pre-wrap",
})
return layout(prepared, width, LINE_HEIGHT).height
총합에 셀 padding(p-2 → 16px), 인라인 그리드 컬럼의 intrinsic minimum, 날짜 컬럼이 wrap될 때 필요한 최소 높이까지 Math.max로 보정한다.
2) 실제 렌더 너비와 어긋나지 않게 맞추기
이게 의외로 중요했다. pretext는 "주어진 너비에서 wrap" 한 결과를 돌려주므로, 넘기는 너비가 실제 셀의 content width와 다르면 line 수가 통째로 어긋난다. 추정 모듈이 아무리 정확해도 여기서 틀리면 전부 무의미해진다.
그래서 clientWidth를 그냥 쓰지 않고 두 축에서 추적했다.
- 테이블 body 너비:
ResizeObserver로 관찰 - 날짜/치식 컬럼 폭:
table.getColumn(...)?.getSize()로 tanstack-table에서 직접 읽음
const descriptionContentWidth = Math.max(
80,
bodyWidth - dateColSize - chisigColSize - 24, // border + scrollbar 여유
)
const rowHeights = useMemo(() => {
if (bodyWidth === 0) return null
return flat.map(row =>
estimateRowHeight(row.originalRecord, descriptionContentWidth),
)
}, [flat, descriptionContentWidth, bodyWidth])
const estimateSize = useCallback(
(index: number) => rowHeights?.[index] ?? 80,
[rowHeights],
)
p-2(좌우 8+8 = 16px) + border + scrollbar gutter를 고려해 24px 여유를 뒀다. clientWidth는 scrollbar를 제외한 값이라, 여기서 scrollbar 보정을 또 빼면 이중으로 깎인다.
3) 너비 변경 시 invalidate
컬럼 리사이즈가 일어나면 wrap 결과가 바뀌므로 추정값과 측정 캐시를 둘 다 무효화해야 한다.
useEffect(() => {
if (bodyWidth > 0) rowVirtualizer.measure()
}, [descriptionContentWidth, rowVirtualizer])
rowVirtualizer.measure()는 캐시된 DOM 측정값을 모두 비우고 estimateSize를 다시 호출하게 만든다.
왜 2단계 구조로 뒀는가
pretext로 추정이 정확해졌다고 measureElement를 떼지는 않았다. 캔버스 글리프 측정은 가볍고 DOM에 영향이 없지만, 폰트 로딩 타이밍에 따라 결과가 미세하게 달라질 수 있다. 그리고 모듈에서 정확히 재현하기 어려운 케이스(span별로 폰트가 바뀌는 서식 텍스트, 인라인 이미지)가 언제든 들어올 수 있다.
그래서 역할을 이렇게 나눴다.
| 단계 | 담당 | 성격 |
|---|---|---|
| off-screen | pretext 추정 | 1차 근사. 틀려도 화면에 안 보임 |
| on-screen | measureElement |
최종 보정. 항상 진짜 높이로 정정 |
추정이 충분히 정확하면 보정 폭이 작아 흔들림이 보이지 않고, 추정이 빗나가도 "결국 화면에 들어오면 진짜 높이로 정정된다" 는 안전장치가 남는다. 정확도를 올리되 정확도에 의존하지는 않는 구조다.
고려사항
측정 비용
모든 행에 대해 추정이 돌지만 pretext는 캔버스 measureText 기반이라 수백 개여도 1회 계산이 ms 단위로 끝난다. useMemo 의존성을 [flat, descriptionContentWidth, bodyWidth]로 잡아, 너비가 픽셀 단위로 안정될 때만 재계산된다.
마운트 직후 0 너비
ResizeObserver가 첫 콜백을 발사하기 전 한 프레임 동안 bodyWidth === 0이 된다. 이때 추정을 돌리면 너비가 80px(min)로 클램프돼 모든 텍스트가 세로로 길게 펼쳐진 값이 나오고, 다음 프레임에 진짜 너비로 바뀌면서 큰 점프가 발생한다. 고치려던 증상을 마운트 시점에 재현하는 셈이다.
그래서 bodyWidth === 0이면 추정을 아예 건너뛰고(return null) fallback 80px로 한 프레임만 돌린다. 화면에 들어가는 몇 행만 measure되고 곧 정상화되므로, 체감상 마운트 직후 한순간만 thumb이 잡히는 정도다.
컬럼 리사이즈 중의 떨림
드래그하는 동안 descriptionContentWidth가 픽셀마다 바뀌고 그때마다 전체 재추정이 일어난다. 수백 행을 다시 재는 비용이 들지만 pretext가 충분히 빨라서 16ms 안에 들어온다. 행 수가 더 늘어 문제가 생기면 리사이즈 종료 시점에만 invalidate하도록 debounce를 걸면 된다. 지금은 매 프레임 재측정.
부수 작업 — 실제 데이터로 재현하기
fixture JSON만으로는 진짜 jank 케이스(기록이 긴 환자)를 재현할 수 없어서, storybook이 API 서버를 직접 호출하도록 story를 하나 추가했다.
- 브라우저 측 fetch가 닿도록 base URL을 rewrite. 설정 파일의
0.0.0.0은 server-bind 전용이라 그대로 쓰면 안 된다 - dev port를 5000 → 5001로 옮김 (5000은 macOS AirPlay Receiver와 충돌한다)
- 렌더러와 동일한 IPC 채널을 흉내내서 같은 경로로 데이터를 받게 함
이 story가 있어야 실제 환자 데이터로 before/after를 재현해 화면 기록을 뜰 수 있었다. 성능 작업은 재현 환경을 만드는 데 드는 시간이 실제 수정보다 긴 경우가 많다.
결과
전체 흐름을 정리하면 이렇다.
| 단계 | 동작 | 비고 |
|---|---|---|
| 1. mount | bodyWidth === 0, fallback 80px |
1프레임 |
| 2. ResizeObserver fire | description 너비 확정 | |
| 3. 전체 행 1차 추정 | pretext로 계산 | DOM 없이 ms 단위 |
| 4. virtualizer 첫 렌더 | 추정 기반 totalSize, 화면 안 행만 마운트 | |
5. on-screen measureElement |
진짜 DOM 높이로 보정 | 추정이 정확하면 변동 미미 |
| 6. 스크롤 | 새 행이 들어와도 변동폭이 작아 thumb 안정 | ← 이번 작업의 효과 |
| 7. 컬럼 리사이즈 | 너비 변경 → measure()로 캐시 invalidate |
실측 보정이 일어나도 getTotalSize() 변동이 0에 수렴하면서 thumb 튐과 콘텐츠 점프가 사라졌다.
Before
After
남은 것 / 한계
- 서식 텍스트 안의 폰트 변경
span별로 font/size를 바꾸는 서식이 들어오면 추정과 실제가 어긋난다.measureElement가 보정하므로 큰 점프는 없지만 완벽하진 않다 - 이미지 aspect ratio
썸네일이max-w-40 / h-auto라서 실제 비율을 모른다. 일단 정사각형 가정(120px)으로 처리했다. 정확히 하려면 데이터에 width/height 메타가 같이 와야 한다 - 인라인 그리드 높이
aspect-ratio: 170/50이라 컨테이너 너비에 따라 실제 높이가 24~35px 사이를 오간다. 36px로 고정 추정하고measureElement에서 정정되도록 뒀다
회고
이미지 피드에서 같은 문제를 만났을 때는 1 / aspectRatio로 풀었다. 이미지는 로드 전에도 비율만 알면 높이가 나오기 때문이다. 텍스트는 그게 안 된다. 같은 문제라도 아이템의 성격에 따라 "무엇으로 높이를 추정할 수 있는가"가 달라진다.
그리고 이번 건에서 가장 오래 걸린 건 코드가 아니라 증상의 이름을 바꾸는 일이었다. "스크롤이 버벅거린다"로 두면 프레임, 리렌더, 메모이제이션 쪽을 뒤지게 된다. "스크롤바 thumb의 길이가 스크롤 중에 계속 바뀐다"로 다시 적고 나서야 getTotalSize()가 용의자로 올라왔고, 거기서 estimateSize까지는 한 걸음이었다.